On the Importance of Errors (oh, and Operator Overloading)

You may remember seeing the following code in the ‘Philosophy’ lectures we were talking about this function:


In [5]:
def net_force(mass, acceleration):
    return mass * acceleration

I argued that one of the main drawbacks of this code is that "[it] implicitly assumes the user knows to pass in numbers". In this lecture I aim to do two things. Firstly, I want you to understand what the problem is from a technical point of view. Secondly, I want you to grasp the importance of what is potentially at stake.


In [6]:
# Function as above, but with DOCUMENTATION 
def net_force(mass, acceleration):
    """
    Calculates f=ma, returns force.
    We assume mass, acceleration are of type int/float.
    """
    return mass * acceleration

In the documentation above we mention clearly what our function does and how it is to be used. Take particular note to the part where we say mass and acceleration are supposed to be floats or integers. Telling our end-user about how we expect our code to be used helps them avoid bugs and other software defects that could result from improper use. BUT in Python (unlike some other statically-typed languages) the 'python way' is to allow the user to do whatever they want; we just trust that they will be responsible.

In the documentation above we mention clearly what our function does and how it is to be used. Take particular note to the part where we say ‘mass’ and ‘acceleration’ are supposed to be floats or integers. Telling our end-user about how we expect our code to be used helps them avoid bugs and other software defects that could result from improper use. But with that said, if somebody wants to misuse your code they should have the freedom to do just that…

Freedom & Responsibility

In Python there is a saying:

"We are all responsible users"

The meaning of that quote is basically "hey, if you use my code I trust that you will call my code how it is intended to be called. If my code breaks because you did something I didn't anticipate then that’s not my fault, its yours”.

And lets not forget that...

"...with great freedom comes great responsibility" ~ Eleanor Roosevelt

This idea of openness and responsibility is very much core to Python philosophy; the language allows you tinker with almost everything, and this incredible freedom is ultimately underpinned by a conventional wisdom that trusts you to be responsible.

Think about it this way; if I write a piece of code that has a bunch of 'helpful warnings' in the documentation but it doesn't ‘hard-wire’ any checks or tests for input then that means other developers have have the ability to use the code in unexpected ways; maybe the behaviour you consider problematic or 'silly' is actually useful for some other developer out-there who wants to use your code for an entirely different purpose.

In short, good Python code is very trusting of others; we should tell others how to use our code (normally in the documentation) but we shouldnt demand that they use our code as we expect them to.

Let me try and explain with an example...


In [8]:
def add(a, b):
    """
    returns a + b, a and b should be integers
    """
    return a + b

def add2(a, b):
    # btw, the "==" symbol is asking the question "is this equal to that". More on this later.
    assert a == type(int), "'{}' is not an integer".format(a)
    assert b == type(int), "'{}' is not an integer".format(b)
    return a + b

'add' and 'add2' are very similar functions, the main difference is that 'add' has a docstring that explains what the function does and also explains what the expected input is. 'add2' doesn't have documentation and it 'forces' the user to pass in integers. We haven't seen the assert statement yet, but basically what it does is it tells Python to throw an error whenever the condition is not met. In this case, if we give this function a string it will not work.


In [17]:
add2("hello", "world")


---------------------------------------------------------------------------
AssertionError                            Traceback (most recent call last)
<ipython-input-17-b17a73f7352f> in <module>()
----> 1 add2("hello", "world")

<ipython-input-8-59e7dc2fe808> in add2(a, b)
      5 def add2(a, b):
      6     # btw, the "==" symbol is asking the question "is a equal to this 'other thing'." More on this later.
----> 7     assert a == type(int), "'{}' is not an integer".format(a)
      8     assert b == type(int), "'{}' is not an integer".format(b)
      9     return a + b

AssertionError: 'hello' is not an integer

Of these two functions 'add' is more Pythonic, the code is more flexible and it trusts the user to be responsible. Meanwhile the function 'add2' is much more rigid and demands to be used in a certain way. Overall you should try to write code more like the 'add' function as opposed to 'add2'.

Moreover since the 'add' function doesn't hard-code requirements this code can be used in unexpected ways...

Operator Overloading...


In [10]:
print( "✓"  +  "✓")    # strings (rememeber strings support *any* unicode character)
print(  1   +   2)      # integers
print( [1]  +  [2])     # lists
print( (1,) +  (2,))    # tuples


✓✓
3
[1, 2]
(1, 2)

The salient point is to notice is that the ‘+’ operator works not just on integers, but also tuples, lists, strings, class objects and so on. This is what we call 'operator overloading'.

Operator overloading occurs when a single operator has several different meaning/uses, depending on context. For numbers, ‘+’ means addition, for strings it means concatenation, and for other objects (i.e. custom classes) it could mean literally whatever we want it to mean.

In many ways, this is a great feature since it means the language ends up being a bit more concise and we have less symbols to remember. But you must be careful; There are bugs ahead!

Lets return to our function "force". Let’s call it with a bunch of objects (of different types) and see what happens!


In [14]:
def net_force(mass, acceleration):
    """ 
    Returns mass * acceleration
    I trust you guys will use this function responsibly.
    """
    return mass * acceleration

print(net_force(10, 10))
print(net_force("2", 10))
print(net_force([1], 10 ))


100
2222222222
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1]

Notice that, just like the '+' operator '*' can mean different things in different contexts, thus we we give our net_force function strings, lists, and integers it behaves differently in each case.


In [16]:
# one more use of the '*' operator, this is called "unpacking"...
x = "12345"
print( *x, sep="_Batman_")


1_Batman_2_Batman_3_Batman_4_Batman_5

Because all of these uses are well-defined Python does not throw an error when you try to multiply an integer by a string; In each case the code "worked", and I'd argue that’s the problem!

Errors love you, love them back

"Errors should never pass silently". ~ Zen of Python

As beginners you might hate it when Python throws error messages at you; it feels like you got something wrong and now Python is punishing you for it. I’d counter that by saying this is very unhealthy attitude to have regarding error messages. I would argue that you should learn to love error messages, you should love them because they are the knights in shining armour that protect you from silly mistakes that could have disastrous consequences.

Am I being overly-dramatic here? Well, consider the situation where you have medical data that you feed through a computer. It parses the data and the computer crashes on some error. What happens next? You reboot, fix, and re-run. Annoying sure, but no big deal.

Now imagine instead of getting an error Python just returns the wrong answer; think about the consequences of that just for a moment, wrong answers could easily have a disastrous impact on patient care.

Another reason you should prefer error messages instead of bad output is because an error in one place of code will probably cause an error much further on down the line (if you are lucky!). And the longer the error goes unnoticed the harder it is to find.

In the cell below I have a function called “Chemotherapy_radiation_dose”. This function is the same as our net_force function, the name change literally is just for dramatic effect.


In [9]:
def chemotherapy_radiation_dose(patient_weight, cancer_stage):
    radiation_dose = patient_weight * cancer_stage
    return radiation_dose

print(chemotherapy_radiation_dose(65, 2))
print(chemotherapy_radiation_dose("65", 2))


130
6565

Okay, we have called the chemo function twice and we get the number 130 and the string 6565 returned to us. We shall use those values in a jiffy. Okay, now lets suppose that part of our program has a 'parse results' function. The task of this function is to get the results ready for printing.


In [20]:
def parse_results(number):
    return "The correct dose for the patient is " + number

parse_results(130)


---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-20-d0f5d0a8fb11> in <module>()
      2     return "The correct dose for the patient is " + number
      3 
----> 4 parse_results(130)

<ipython-input-20-d0f5d0a8fb11> in parse_results(number)
      1 def parse_results(number):
----> 2     return "The correct dose for the patient is " + number
      3 
      4 parse_results(130)

TypeError: must be str, not int

So in the above code I called parse_results with the number 130 and the function returns an error. Now, since are expecting to be given numbers as input and we want to return a string it makes sense to use str() to convert the number. Afterall, a program that spins errors when we give it correct input is of no use at all.


In [19]:
def parse_results(number):
    return "The correct dose for the patient is " + str(number)
#                                                   ^ The change is right here

print(parse_results(130))
print(parse_results("6565"))


The correct dose for the patient is 130
The correct dose for the patient is 6565

So okay, we add str(number) to avoid spinning errors on valid inputs. That is totally fine. BUT notice there is a nasty and insidious side-effect to doing this; from this point on "6535" is going to look like good data because all numbers are now strings.

Just stop and think about that for a moment.

From this point on the only clue you have that something went wrong is the sheer magnitude of the number, ERROR MESSAGES CAN'T SAVE US NOW!

If we are lucky a human operator might realise that 6565 is a lot way higher than it should be and as a result double checks the data and finds the mistake. But, the magnitude of error could easily be concealed by some other function. For example, suppose the operator of the 'big-cancer-zappy-machine' doesn’t see the raw numbers, rather, he/she sees the log of those numbers...

log10(130)  = 2.11394335231
log10(6565) = 3.81723473043

... and now the sheer magnitude of the fuck-up is masked. It is so easy to imagine some unaware lab technician not realising that there is a huge difference between 2.11 and 3.81.

The result? A patient gets a radiation dose 50x more powerful than it should be!

Therac-25, or why this shit matters...

And before you consider all this a bit far-fetched, it is worth considering that medical accidents involving software bugs have happened before. The Therac-25 was a radiation therapy machine that on least six separate occasions delivered a radiation dose 100x more powerful than they should have been; all due to a software bug.

A commission looking into these incidents cited several safety breaches and poor practices that lead to this disaster. For our purposes, this paragraph stands out:

"The system noticed that something was wrong and halted the X-ray beam, but merely displayed the word "MALFUNCTION" followed by a number from 1 to 64. The user manual did not explain or even address the error codes, so the operator pressed the P key to override the warning and proceed anyway." ~ Wikipedia article

What can we learn from this?

  1. It might not be a good idea to simply report "MALFUNCTION 15" when you are killing people (readability counts).
  2. If you must have a error message as abstruse as "MALFUNCTION 15" it might be a good idea to jot down "This means you are killing people" somewhere (documentation matters).
  3. Maybe make it harder to kill people than merely typing "P" on the keyboard? (serious errors should be given weight)

Basically the Therac-25 machine did raise errors but the mistake made was to not have a those errors mean anything to the end-user. Because the danger was not made explicit people literally died; When things go wrong it is crucial that error messages are not only raised but also respected. Personally I think the moral of the story here is that when things go wrong it is crucial that error messages are not only raised but also respected.

Now, most of the time you won't be writing medical software where life and death is litterally at stake. Nonetheless I think the general point stands; errors are often better than bad data. And for that reason, writing code that raises a lot of errors when used incorrectly is actually fine. But when errors are raised they should be (a) easy to interpret and (b) as close to the source of the problem as possible.

Conclusions...

So what are the lessons to be learned from today's lecture? Well we learnt about what operator overloading is and how, if we are not careful this can lead to software bugs. We also learnt that the 'Python way' is to give the end-user a lot of freedom to use our code how they please but that freedom is implicitly built on trust. This is one of the reasons why good documentation matters.

And finally, we also learnt that having error messages pop up every once in awhile is often preferable to returning a bad output. As beginners error messages may feel like a major pain in ass but ultimately they are there to help you write correct code. I hope that my medical example above gives you a different perspective on error messages.


In [ ]: